Story 5.6: the blocker proposes every pair, and the floor is an integer - #59
Merged
Merged
Conversation
The candidate generator is now an explicit component. `candidates()` returns every unordered pair of DISTINCT observation ids as a `BTreeSet<CandidatePair>`, total by decision rather than by omission: D13 disposes of the performance argument itself (90k pairs at 300 hosts is noise), and every exclusion a blocker makes is a false split it can never be talked out of. `CandidatePair` has private fields ordered by its constructor, so `new(a, b) == new(b, a)` holds by construction; `new(a, a)` is `None`, which gives the self-pair precondition its first holder — `decide_pair` answers it today and says only that the pair "arrives as an argument". D13's `blocking_recall >= 0.999` ships as `BLOCKING_RECALL_FLOOR_PER_MILLE: u32 = 999`, on D13's own milli-units corollary and on the architecture's ratified test name, which already carries no float. The `float-free` gate walks 4 files under identity/ now, unweakened. Measured against the committed corpus, in fixtures.rs's test module because the domain crate may not read files (D47): 24 traps, 23 name a pair, 10 must-merge, recall 1000 per-mille. The containment assertion and the recall value are two tests on purpose — in one, a missing pair panics before any recall is computed. 309 -> 332 tests (139 bin + 147 core + 46 xtask). Six gates green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HEE7mz4xufqYW5xKfgJBaF
The five falsified doc sites are corrected — identity/mod.rs, cascade.rs's
Decision, l1.rs twice, and lib.rs, whose stated reason for not flat-re-exporting
join ('its consumer, the candidate generator, does not exist yet') is exactly
what this story falsifies. A sixth site was found by RE-READING rather than by
grep: identity/mod.rs's D54 paragraph, which turned out to be TRUE
(verdict_for_pair is pub(crate) — the crate, not the subtree), so it was
verified and annotated instead of rewritten.
Register: R1 (the float) and R2 (the self-pair) disposed of by TITLE, three new
entries opened, and four belonging to other stories read and left open — the
&str allocation entry's condition is measured NOT met, since candidates calls no
rule at all.
All six mutations run against a committed baseline, every restore verified by
md5sum and git status. Zero compiler-carried reds. Three divergences from the
predictions are stated rather than left implicit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HEE7mz4xufqYW5xKfgJBaF
Three review layers (Blind Hunter, Edge Case Hunter, Acceptance Auditor).
Nine patches applied, two deferrals registered with owners, eleven findings
dismissed after re-measurement. 332 -> 333 tests (139 bin + 148 core + 46
xtask); six gates green, `float-free` still over 4 files, corpus 25/25.
The HIGH was a doc refuted by a measurement inside the same commit. The
module head claimed blocking on the MAC, the hostname OR the `l2_domain`
would each pass the whole committed corpus. Measured over the ten
`must-merge` pairs by parsing the TOML and JSONL directly: MAC 7/10 = 700
per-mille — which is the story's OWN M1 figure, twelve lines below the
sentence it refutes — hostname 4/10 = 400, `l2_domain` 10/10 = 1000. Only
the third passes, and it is the one the corpus is blind to. The head doc now
carries the three figures.
The one behavioural gap: nothing fed `candidates` an observation with EMPTY
`facts`, so a `facts.is_empty()` narrowing was measured GREEN across all 332
tests — the same mutation class as M2, but M2 has a synthetic test and this
had nothing. 0 of the 51 committed observations has empty facts, so the
corpus could never have caught it. One test added, proven red against it.
Six of the nine patches were sentences rather than behaviour, the fifth
story running with that shape: `decide_pair(a, a)` was documented as
answering with a merge when it abstains on a MAC-less observation (two doc
sites plus the register); three sites named the trap runner as the blocker's
consumer in the present tense while the register in the same commit said it
has no production caller at all; the register announced three new entries
and opened four; and the floor's tolerance boundary was off by one — it
opens AT 1000 required pairs, not above.
Two behavioural weakenings fixed: the floor assertion now runs before the
exact-value one, so a narrowed blocker reds with D13's threshold rather than
a bare equality (`>=` to `>` had left the workspace green), and the superset
test asserts its exact three grouped pairs.
Three prove-to-red runs on what the patches changed, restores verified by
md5sum, all assertion-carried, zero compiler-carried:
MV1 fact-presence narrowing -> the new test reds alone (1 core)
MV2 superset fixture degraded -> `checked` measured at 1, so the old
`assert!(checked > 0)` was green there; `assert_eq!(checked, 3)` reds
MV3 one required pair dropped -> "recall is 900 per-mille, below the
floor of 999" (1 bin)
Status stays `review`: `done` is the merge's business in this project's
flow, as 5.1 through 5.5 all decided.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HEE7mz4xufqYW5xKfgJBaF
guycorbaz
added a commit
that referenced
this pull request
Aug 1, 2026
Bookkeeping only — **no code changes**. Follows the same shape as #56 (5.4b) and #58 (5.5). - `sprint-status.yaml`: `5-6-blocker-and-recall-assertion: review → done`, with the review's outcome recorded above the entry. - The story file's `Status:` follows. - `CLAUDE.md` and `docs/project-context.md` now describe what **merged** (`020c706`) rather than what was in flight, and Epic 5 reads **8 of 16 done**. **5.6 was code-reviewed before the merge** (three layers: Blind Hunter, Edge Case Hunter, Acceptance Auditor): **9 patches applied, 2 deferrals registered with owners, 11 findings dismissed after re-measurement**. 332 → **333 tests**. The docs carry the two measured findings rather than a summary of them: - **The HIGH is a doc refuted by a measurement inside the same commit.** `blocking.rs`'s head claimed blocking on the MAC, the hostname *or* the `l2_domain` would each pass the whole committed corpus. Measured over the ten `must-merge` pairs: MAC **7/10 = 700‰** — which is the story's own M1 figure, twelve lines below the sentence it refutes — hostname **4/10 = 400‰**, `l2_domain` **10/10 = 1000‰**. Only the third passes, and it is the one the corpus is blind to. - **The one behavioural gap:** nothing fed `candidates` an observation with **empty `facts`**, so a `facts.is_empty()` narrowing was measured GREEN across all 332 tests. 0 of the 51 committed observations has empty facts, so the corpus could never have caught it. One test added, proven red against it. Six of the nine patches were **sentences, not behaviour** — the fifth story running with that shape. `views-hash` remains `ℹ STALE` by design (issue #50, a milestone act). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01HEE7mz4xufqYW5xKfgJBaF Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The candidate generator is now an explicit component with a measured recall floor, so a false split
cannot be born silently before any rule has had a chance to speak.
What ships
crates/opencmdb-core/src/identity/blocking.rs— the file the architecture already names[architecture.md:3368] — holds:
CandidatePair, private fields ordered by its constructor, sonew(a, b) == new(b, a)holdsbecause the two calls build the same value.
new(a, a)isNone: the self-pair precondition,which
decide_pair's doc left ownerless, finally has a holder — in the type, not in a comment.candidates(&[Observation]) -> BTreeSet<CandidatePair>— every unordered pair of DISTINCTobs_ids, TOTAL by decision. D13 disposes of the performance argument itself ("the blocker isnot there for performance — 90k pairs is noise on a NAS i5"), and it calls neither
joinnordecide_pair: proposing is not judging, and a blocker that consults a rule is that rule's echo.BLOCKING_RECALL_FLOOR_PER_MILLE: u32 = 999andblocking_recall_per_mille, returningOption<u32>—Nonefor an empty denominator, because a recall with no denominator isundefined, not perfect.
The float was typed, not avoided
D13 hands this story
blocking_recall >= 0.999, which is a bare float literal that reds thefloat-freegate — measured during story 5.5 and flagged forward on purpose. The gate is notweakened, skipped,
#[allow]ed or narrowed: it walks 4.rsfiles underidentity/now whereit walked 3. The value is D13's, unchanged; only its TYPE moved, on D13's own milli-units corollary
("an INTEGER in milli-units (0..1000), never REAL/DOUBLE") and on the architecture's ratified test
name
blocking_recall_above_999, which already carried no float and is used verbatim.Why this is not the recall gate D18 refuses by name
D18 puts pairwise recall in Tier 2, blocking nothing, because "a gate that cannot fall is
decoration". The module doc owes and carries the distinction: different subject (generator input
coverage, not engine output), different venue (a unit test over the frozen corpus, not a release
gate) — and the honest arithmetic that at a 10-pair denominator
>= 999‰IS zero-tolerance, andbecomes a real tolerance only past 1000 required pairs, where NFR4's "any fraction is theatre"
would bite. That day is registered, not inherited. Nothing here advances NFR4.
The corpus is the truth set, and its counts are asserted
Because D47 forbids the domain crate to read files, the corpus half lives in
opencmdb-bin'sfixtures.rstest module — nothing above#[cfg(test)]changed and no newpubitem appears inbin. Measured by the shipped assertions, not quoted: 24 traps, 23 naming a pair, 1 residue
(
example-must-abstain), 0 naming more than two, 10must-mergepairs, recall 1000‰.blocking_recall_above_999computes only the recall; the per-trap containment assertion is aseparate test. That split is load-bearing and was measured: in one test, a missing pair panics on
docker-veth-must-mergebefore any recall is computed, and the number you would report is the panic.Prove-to-red
Six mutations, run against a committed baseline, each restore verified by
md5sumandgit status. Zero compiler-carried reds.l2_domainn*(n-1)/2count test correctly does NOT redSome(1000)on an emptyrequiredNonecase reds900M2 is the one that matters: all 23 committed trap pairs are same-scope, so a
l2_domain-blockedblocker scores a full 1000‰ and is invisible to every corpus test. One synthetic cross-domain test
stands between that wrong blocker and green — which is why the story required it to be written first.
Three divergences from the predictions are stated rather than left implicit: M4's bin count is 139,
not the predicted 138 (same verdict, one more test); M1's bin red set is 3, not 2, the extra being
the containment test that did not exist at contexting; M1's core red set is 4, where the story
estimated "several".
Docs and register
The five falsified doc sites are corrected, plus a sixth found by re-reading and not by grep —
identity/mod.rs's D54 paragraph, which turned out to be true (verdict_for_pairispub(crate), which is the crate and not the subtree), so it was verified and annotated rather thanrewritten. Register: R1 (the float) and R2 (the self-pair) disposed of by TITLE; three new
entries opened (D17's
dormantexclusion, unimplementable because no lifecycle state exists —measured; the quadratic universe; the floor's arithmetic past 1000 pairs); four entries belonging to
other stories read and left open, including the
&strallocation one whose condition is measurednot met, since
candidatescalls no rule.Numbers
309 → 332 tests (139 bin + 147 core + 46 xtask), re-counted mechanically after the last edit.
Six green gates plus
ℹ views-hash STALE(exit 0 by design, issue #50).fixtures/and the planningartifacts are byte-unchanged.
What this does NOT do
No verdict is produced, no
Decisionis built, anddecide/decide_pair/verdict_for_pairare notcalled.
trap_gate.rswas not opened; noFrom<Decision> for Outcomeappears (5.7). The blockerhas no production caller — it is reached from its own tests and from
fixtures.rs's test module,the same shape
decidewas in before story 5.5.🤖 Generated with Claude Code
https://claude.ai/code/session_01HEE7mz4xufqYW5xKfgJBaF